前面學會了 NSubstitute 的依賴模擬和 AutoFixture 的資料產生。但實際開發時,當服務類別有多個相依性,手動建立每個 Mock 物件會讓測試程式碼變得冗長。AutoFixture.AutoData 提供了更簡潔的解決方案,可以自動處理相依性注入並產生測試資料。
這篇來看看如何結合 AutoFixture.AutoData 與 NSubstitute,讓測試寫得更有效率。
AutoFixture.AutoNSubstitute 是 AutoFixture 生態系統中的一個擴充套件,專門用來整合 NSubstitute 模擬框架。它提供了自動模擬(Auto-Mocking)功能,能夠自動為介面和抽象類別建立 NSubstitute 的替身物件。
NuGet Package: AutoFixture.AutoNSubstitute
套件連結: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute/
官方文件: https://github.com/autofixture/autofixture#mocking-libraries
安裝 NuGet Package:
dotnet add package AutoFixture.AutoNSubstitute
當我們在 AutoFixture 中加入 AutoNSubstituteCustomization
時,它會自動:
Substitute.For<T>()
建立 Mock 物件using AutoFixture;
using AutoFixture.AutoNSubstitute;
// 建立包含 AutoNSubstitute 功能的 Fixture
var fixture = new Fixture().Customize(new AutoNSubstituteCustomization());
// 自動建立服務和其相依性
var service = fixture.Create<MyService>();
// MyService 的所有介面相依性都會自動變成 NSubstitute 的替身
傳統手動方式:
[Test]
public void TraditionalWay()
{
// Arrange - 手動建立每個相依性
var repository = Substitute.For<IRepository>();
var logger = Substitute.For<ILogger>();
var notificationService = Substitute.For<INotificationService>();
var sut = new OrderService(repository, logger, notificationService);
// 設定替身行為
repository.GetOrder(Arg.Any<int>()).Returns(someOrder);
// Act & Assert...
}
使用 AutoNSubstitute:
[Theory]
[AutoData]
public void WithAutoNSubstitute(OrderService sut, [Frozen] IRepository repository)
{
// Arrange - 相依性已自動建立,只需設定需要的行為
repository.GetOrder(Arg.Any<int>()).Returns(someOrder);
// Act & Assert...
}
AutoFixture.AutoNSubstitute 主要解決以下問題:
在 AutoFixture.Xunit 中,[Frozen]
屬性用來控制測試中某個類型的實例。當參數被標註為 [Frozen]
時,AutoFixture 會建立這個類別的一個實例並凍結它,後續在測試方法中都會使用同一個已凍結的實例。
這個機制特別適合有許多相依注入的測試目標類別,可以保證測試的穩定性和一致性。
首先,我們需要建立一個自訂的 AutoData 屬性來整合 AutoNSubstitute。
using AutoNSubstitute.Core.Dto;
using AutoNSubstitute.Core.Entities;
using AutoNSubstitute.Core.Misc;
using AutoNSubstitute.Core.Repositories;
using AutoNSubstitute.Core.Validation;
using MapsterMapper;
using Throw;
namespace AutoNSubstitute.Core.Services;
/// <summary>
/// 出貨商服務實作
/// </summary>
public class ShipperService : IShipperService
{
private readonly IMapper _mapper;
private readonly IShipperRepository _shipperRepository;
/// <summary>
/// 建構函式
/// </summary>
/// <param name="mapper">對應器</param>
/// <param name="shipperRepository">出貨商資料庫</param>
public ShipperService(IMapper mapper, IShipperRepository shipperRepository)
{
this._mapper = mapper;
this._shipperRepository = shipperRepository;
}
/// <summary>
/// 以 ShipperId 查詢資料是否存在
/// </summary>
/// <param name="shipperId">出貨商編號</param>
/// <returns>是否存在</returns>
public async Task<bool> IsExistsAsync(int shipperId)
{
shipperId.Throw().IfLessThanOrEqualTo(0);
var exists = await this._shipperRepository.IsExistsAsync(shipperId);
return exists;
}
/// <summary>
/// 以 ShipperId 查詢出貨商資料
/// </summary>
/// <param name="shipperId">出貨商編號</param>
/// <returns>出貨商資料</returns>
public async Task<ShipperDto> GetAsync(int shipperId)
{
shipperId.Throw().IfLessThanOrEqualTo(0);
var exists = await this._shipperRepository.IsExistsAsync(shipperId);
if (!exists)
{
return null;
}
var model = await this._shipperRepository.GetAsync(shipperId);
var shipper = this._mapper.Map<ShipperModel, ShipperDto>(model);
return shipper;
}
/// <summary>
/// 搜尋出貨商資料
/// </summary>
/// <param name="companyName">公司名稱</param>
/// <param name="phone">電話號碼</param>
/// <returns>符合條件的出貨商資料</returns>
public async Task<IEnumerable<ShipperDto>> SearchAsync(string companyName, string phone)
{
if (string.IsNullOrWhiteSpace(companyName) && string.IsNullOrWhiteSpace(phone))
{
throw new ArgumentException("companyName 與 phone 不可都為空白");
}
var totalCount = await this.GetTotalCountAsync();
if (totalCount.Equals(0))
{
return [];
}
var models = await this._shipperRepository.SearchAsync(companyName ?? string.Empty, phone ?? string.Empty);
var shippers = this._mapper.Map<IEnumerable<ShipperDto>>(models);
return shippers;
}
/// <summary>
/// 新增
/// </summary>
/// <param name="shipper">出貨商資料</param>
/// <returns>執行結果</returns>
public async Task<IResult> CreateAsync(ShipperDto shipper)
{
ModelValidator.Validate(shipper, nameof(shipper));
var model = this._mapper.Map<ShipperDto, ShipperModel>(shipper);
var result = await this._shipperRepository.CreateAsync(model);
return result;
}
// 其他方法實作...
}
因為測試目標使用 Mapster 而非 AutoMapper,我們需要建立對應的客製化。
為什麼不讓 AutoNSubstitute 自動處理?
雖然 AutoNSubstitute 可以自動為 IMapper
介面建立替身物件,但這並不是我們想要的結果:
因此,我們選擇建立真實的 Mapster 設定,讓 AutoFixture 注入已設定好的 IMapper 實例:
using AutoFixture;
using AutoNSubstitute.Core.MapConfig;
using Mapster;
using MapsterMapper;
namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;
/// <summary>
/// Mapster 對應器客製化
/// </summary>
public class MapsterMapperCustomization : ICustomization
{
/// <summary>
/// 客製化 Fixture
/// </summary>
/// <param name="fixture">Fixture 實例</param>
public void Customize(IFixture fixture)
{
fixture.Register(() => this.Mapper);
}
private IMapper? _mapper;
private IMapper Mapper
{
get
{
if (this._mapper is not null)
{
return this._mapper;
}
var typeAdapterConfig = new TypeAdapterConfig();
typeAdapterConfig.Scan(typeof(ServiceMapRegister).Assembly);
this._mapper = new Mapper(typeAdapterConfig);
return this._mapper;
}
}
}
為了在測試中同時使用 AutoNSubstitute 的自動模擬功能和 Mapster 的真實對應器,我們需要建立自訂的 AutoData 屬性。
AutoDataWithCustomizationAttribute 的設計目的:
CreateFixture 方法的處理行為:
new Fixture()
建立基本的 AutoFixture 實例.Customize(new AutoNSubstituteCustomization())
啟用自動模擬.Customize(new MapsterMapperCustomization())
注入真實的對應器設定using AutoFixture;
using AutoFixture.AutoNSubstitute;
using AutoFixture.Xunit2;
namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;
/// <summary>
/// 包含客製化設定的 AutoData 屬性
/// </summary>
public class AutoDataWithCustomizationAttribute : AutoDataAttribute
{
/// <summary>
/// 建構函式
/// </summary>
public AutoDataWithCustomizationAttribute() : base(CreateFixture)
{
}
private static IFixture CreateFixture()
{
var fixture = new Fixture().Customize(new AutoNSubstituteCustomization())
.Customize(new MapsterMapperCustomization());
return fixture;
}
}
在某些測試場景中,我們需要同時使用固定的測試值(如邊界值、特殊值)和自動產生的物件。這時候就需要 InlineAutoData 的功能。
InlineAutoDataWithCustomizationAttribute 的設計目的:
與純 AutoData 的差異:
CreateFixture 方法的處理行為:
using AutoFixture;
using AutoFixture.AutoNSubstitute;
using AutoFixture.Xunit2;
namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;
/// <summary>
/// 包含客製化設定的 InlineAutoData 屬性
/// </summary>
public class InlineAutoDataWithCustomizationAttribute : InlineAutoDataAttribute
{
/// <summary>
/// 建構函式
/// </summary>
/// <param name="values">固定值</param>
public InlineAutoDataWithCustomizationAttribute(params object[] values)
: base(new AutoDataWithCustomizationAttribute(), values)
{
}
}
為什麼要改用 new AutoDataWithCustomizationAttribute()
而不是 CreateFixture
方法?
InlineAutoDataAttribute 的設計原理:
InlineAutoDataAttribute
繼承自 CompositeDataAttribute
AutoDataAttribute
實例作為資料來源提供者Func<IFixture>
委派正確的建構函式簽章:
// InlineAutoDataAttribute 的建構函式
public InlineAutoDataAttribute(AutoDataAttribute autoDataAttribute, params object[] values)
錯誤實作的問題:
// X 錯誤:直接傳遞方法群組會導致型別不匹配
public InlineAutoDataWithCustomizationAttribute(params object[] values)
: base(CreateFixture, values) // CreateFixture 是 Func<IFixture>,不是 AutoDataAttribute
// O 正確:傳遞 AutoDataAttribute 實例
public InlineAutoDataWithCustomizationAttribute(params object[] values)
: base(new AutoDataWithCustomizationAttribute(), values)
重用現有邏輯的優勢:
CreateFixture
方法AutoDataWithCustomizationAttribute
的行為完全一致AutoDataWithCustomizationAttribute
的設定變更時,InlineAutoDataWithCustomizationAttribute
會自動同步型別安全性:
ShipperServiceBasicTests.cs
[Theory]
[AutoDataWithCustomization]
public async Task IsExistsAsync_輸入的ShipperId為0時_應拋出ArgumentOutOfRangeException(ShipperService sut)
{
// Arrange
var shipperId = 0;
// Act
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => sut.IsExistsAsync(shipperId));
// Assert
exception.Message.Should().Contain(nameof(shipperId));
}
在這個測試中,sut
(System Under Test)會自動由 AutoFixture 建立,包含所有必要的相依性。
[Theory]
[AutoDataWithCustomization]
public async Task IsExistsAsync_輸入的ShipperId_資料不存在_應回傳false(
[Frozen] IShipperRepository shipperRepository,
ShipperService sut)
{
// Arrange
var shipperId = 99;
shipperRepository.IsExistsAsync(Arg.Any<int>()).Returns(false);
// Act
var actual = await sut.IsExistsAsync(shipperId);
// Assert
actual.Should().BeFalse();
}
透過 [Frozen]
屬性,我們可以取得相依性的 Stub,並設定其行為。
[Theory]
[AutoDataWithCustomization]
public async Task GetAsync_輸入的ShipperId_資料有存在_應回傳model(
[Frozen] IShipperRepository shipperRepository,
ShipperService sut,
ShipperModel model)
{
// Arrange
var shipperId = model.ShipperId;
shipperRepository.IsExistsAsync(Arg.Any<int>()).Returns(true);
shipperRepository.GetAsync(Arg.Any<int>()).Returns(model);
// Act
var actual = await sut.GetAsync(shipperId);
// Assert
actual.Should().NotBeNull();
actual.ShipperId.Should().Be(shipperId);
}
這裡的 model
也是由 AutoFixture 自動產生,包含合理的測試資料。
[Theory]
[InlineAutoDataWithCustomization(0, 10, nameof(from))]
[InlineAutoDataWithCustomization(-1, 10, nameof(from))]
[InlineAutoDataWithCustomization(1, 0, nameof(size))]
[InlineAutoDataWithCustomization(1, -1, nameof(size))]
public async Task GetCollectionAsync_from與size輸入不合規格內容_應拋出ArgumentOutOfRangeException(
int from, int size, string parameterName, ShipperService sut)
{
// Act
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => sut.GetCollectionAsync(from, size));
// Assert
exception.Message.Should().Contain(parameterName);
}
結合固定的測試數值與自動產生的 SUT。
[Theory]
[AutoDataWithCustomization]
public async Task GetAllAsync_資料表裡有10筆資料_回傳的集合裡有10筆(
[Frozen] IShipperRepository shipperRepository,
ShipperService sut,
[CollectionSize(10)] IEnumerable<ShipperModel> models)
{
// Arrange
shipperRepository.GetAllAsync().Returns(models);
// Act
var actual = await sut.GetAllAsync();
// Assert
actual.Should().NotBeEmpty();
actual.Should().HaveCount(10);
}
[Theory]
[AutoDataWithCustomization]
public async Task SearchAsync_companyName輸入資料_phone無輸入_有符合條件的資料_回傳集合應包含符合條件的資料(
IFixture fixture,
[Frozen] IShipperRepository shipperRepository,
ShipperService sut)
{
// Arrange
var models = fixture.Build<ShipperModel>()
.With(x => x.CompanyName, "test")
.CreateMany(1);
shipperRepository.GetTotalCountAsync().Returns(10);
shipperRepository.SearchAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(models);
const string companyName = "test";
const string phone = "";
// Act
var actual = await sut.SearchAsync(companyName, phone);
// Assert
actual.Should().NotBeEmpty();
actual.Should().HaveCount(1);
actual.Any(x => x.CompanyName == companyName).Should().BeTrue();
}
由於 SearchAsync
方法包含參數驗證邏輯,我們也需要測試這些驗證規則:
[Theory]
[InlineAutoDataWithCustomization(null, null)]
[InlineAutoDataWithCustomization("", "")]
[InlineAutoDataWithCustomization(" ", " ")]
public async Task SearchAsync_companyName與phone都為空白_應拋出ArgumentException(
string companyName, string phone, ShipperService sut)
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(
() => sut.SearchAsync(companyName, phone));
exception.Message.Should().Contain("companyName 與 phone 不可都為空白");
}
在這個範例中,我們透過 IFixture
參數來精確控制測試資料的產生。
建議使用的場景:
謹慎使用的場景:
IFixture
參數精確控制資料產生今天學了如何將 NSubstitute 與 AutoFixture 整合,透過 AutoFixture.AutoNSubstitute 套件讓測試寫得更有效率。
這種整合方式讓我們能夠:
從 Day 7 學會 NSubstitute 的基本應用,Day 10-12 掌握 AutoFixture 的各種功能,到今天學會兩者的整合應用,現在有了一套完整的測試工具。這些工具讓我們能應對各種複雜的測試場景,同時保持程式碼簡潔好維護。
重要的是要記住,工具是為了解決問題而存在的。實際應用時,要根據專案需求、團隊能力和維護成本來決定是否採用這種方式。測試的可讀性、可維護性和實用性永遠是優先考量。
明天我們將學習另一個測試資料產生工具 Bogus,探討它與 AutoFixture 的差異,以及在不同場景下的選擇策略。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第十三天。明天會介紹 Day 14 – Bogus 入門:與 AutoFixture 的差異比較。